refactor(providers): extract StreamAbortGuard helper for streaming cancel#146
Merged
ericleepi314 merged 1 commit intoMay 15, 2026
Conversation
…ncel PRs #144 and #145 added abort-signal-aware streaming for three providers (Anthropic, Minimax, OpenAI-compatible). The pattern across all three is structurally identical — pre-call fast-path, register- then-recheck listener closing the stream's response on abort, signal- state-authoritative exception translation, listener detachment, post- stream recheck — but the bookkeeping was inlined and triplicated. Adding a fourth provider would have meant a fourth copy. Extract the pattern into ``src/providers/_stream_abort.py``: * ``StreamAbortGuard(abort_signal)`` — one per call. ``abort_signal=None`` makes every method a no-op so providers can use it unconditionally. * ``raise_if_pre_aborted()`` — pre-call fast-path raising ``AbortError``. * ``raise_if_post_aborted()`` — same check at a different boundary. * ``aborted`` property — cheap in-loop check (OpenAI-compat path). * ``reraise_if_aborted(original_exc)`` — translate SDK exceptions to ``AbortError`` via ``raise ... from``; no-op if signal didn't fire. * ``with guard.attach(stream):`` — register a listener that closes ``stream.response`` on abort; detach in ``__exit__``. ``__exit__`` ALSO closes the response if ``signal.aborted`` is True at exit, as a safety net for the race window where ``AbortSignal._fire`` snapshots the listener list, the consumer thread observes ``aborted=True``, breaks out, detaches the listener, and the snapshot's iteration then runs against an empty list — that path would otherwise leak the underlying httpx response open. The close is idempotent on httpx (``if not self.is_closed`` guard) so the double-fire path is safe. Provider line counts: anthropic_provider.py -88 lines minimax_provider.py -73 lines openai_compatible.py -212 lines _stream_abort.py +211 (mostly docstring) Net: providers shrink from 374 lines of abort bookkeeping to 110 (SDK-specific iteration shape only). The provider-level tests from #144 and #145 (15 tests across three files) pass unmodified, proving behavior preservation end-to-end. Seventeen new unit tests in ``tests/test_stream_abort_guard.py`` pin the helper's contract directly: pre/post fast-paths, ``aborted`` property semantics, ``attach`` lifecycle (register, detach, race- recovery via register-then-recheck), close-failure tolerance, no- response-attribute graceful degradation, ``reraise_if_aborted`` translation + cause chaining + no-op, and — critically — the ``__exit__`` close-on-abort safety net. The safety-net test mutation-verified: removing the ``__exit__`` close branch makes the test fail with the exact expected assertion. The OpenAI-compat in-loop test gains a ``stream.response.close.called`` assertion to pin the close at the provider level too. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
StreamAbortGuardhelper atsrc/providers/_stream_abort.pyowns the abort lifecycle; providers keep only their SDK-specific iteration shape.Why
The Critic reviews of both #144 and #145 called out the duplication and recommended a follow-up extraction once the pattern stabilized. User explicitly asked for this in conversation: "yes please implement this follow-up."
What
StreamAbortGuardprovidesKey invariant: while attached,
stream.responseis closed on abort. This is enforced by both the listener firing path AND a safety-net close in__exit__— closes the race window whereAbortSignal._firesnapshots the listener list, the consumer thread observesaborted=True, breaks out, and detaches the listener before the snapshot's iteration runs.httpx.Response.close()is idempotent (if not self.is_closedguard), so the double-fire path is harmless.Line counts
anthropic_provider.pyminimax_provider.pyopenai_compatible.py_stream_abort.py(new)Test plan
abortedproperty, attach lifecycle, race-recovery via register-then-recheck, close-failure tolerance, no-response-attribute graceful degradation,reraise_if_abortedtranslation + cause chaining,__exit__close-on-abort safety net.__exit__close branch and confirmedtest_exit_closes_stream_when_signal_aborted_no_listener_firefails with "Expected 'close' to have been called." Restored, test passes.__exit__— and adding the race + happy-path tests).Dependencies
Stacked on #145 (the OpenAI-compat fix). Rebase onto
mainonce #145 merges.🤖 Generated with Claude Code